/*
 * Copyright (C) 2009 Google Inc.  All rights reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.zinnaworks.smart_cloud_gamepad;

import android.app.Service;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.AsyncTask;
import android.os.Binder;
import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.util.Log;

import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.UnknownHostException;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

import javax.net.ssl.KeyManager;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;

import com.zinnaworks.smart_cloud_gamepad.util.Debug;
import com.zinnaworks.smart_cloud_gamepad.util.LimitedLinkedHashMap;
import com.zinnaworks.smart_cloud_gamepad.protocol.AnymoteSender;
import com.zinnaworks.smart_cloud_gamepad.protocol.DummySender;

/**
 * The central point to connect to a remote box and send commands.
 *
 */
public final class CoreService extends Service implements ConnectionManager {


	private static CoreService instance;
	
	public static CoreService getInstance()
	{
		if(instance == null)
		{
			instance = new CoreService();
		}
		return instance;
	}

	private static final String LOG_TAG = "TvRemoteCoreService";

	/**
	 * Connection status enumeration.
	 */
	public enum ConnectionStatus {
		/**
		 * Connection successful.
		 */
		OK,
		/**
		 * Error while creating socket or establishing connection.
		 */
		ERROR_CREATE,
		/**
		 * Error during SSL handshake.
		 */
		ERROR_HANDSHAKE
	}

	private ConnectionListener connectionListener;

	private Socket sendSocket;

	private RemoteDevice target;

	private LimitedLinkedHashMap<InetAddress, RemoteDevice> recentlyConnected;

	/**
	 * Key store manager.
	 */
	private KeyStoreManager keyStoreManager;

	private Handler handler;

	private ConnectionTask connectionTask;

	private static final Map<State, Set<State>> ALLOWED_TRANSITION
	= allowedTransitions();

	private enum State {
		IDLE,
		CONNECTING,
		CONNECTED,
		DISCONNECTING,
		DEVICE_FINDER,
		PAIRING
	}

	/**
	 * Various tags used to store the service's configuration.
	 */
	private static final String SHARED_PREF_NAME = "CoreServicePrefs";
	private static final String DEVICE_NAME_TAG = "DeviceName";
	private static final String DEVICE_IP_TAG = "DeviceIp";
	private static final String DEVICE_PORT_TAG = "DevicePort";

	/**
	 * Notable values for ports, ip addresses and target names.
	 */
	private static final int MIN_PORT = 0;
	private static final int MAX_PORT = 0xFFFF;
	private static final int INVALID_PORT = -1;
	private static final String INVALID_IP = "no#ip";
	private static final String INVALID_TARGET = "no#target";

	/**
	 * Timeout when creating a socket.
	 */
	private static int SOCKET_CREATION_TIMEOUT_MS = 300;

	/**
	 * Timeout when reconnecting.
	 */
	private static final int RECONNECTION_DELAY_MS = 1000;

	private static final int MAX_CONNECTION_ATTEMPTS = 3;

	/**
	 * Sender that uses the Anymote protocol.
	 */
	private AnymoteSender anymoteSender;

	public CoreService() {
		target = null;
		sendSocket = null;
	}

	@Override
	public void onCreate() {
		super.onCreate();
		
		handler = new Handler(new ConnectionRequestCallback());

		recentlyConnected = new LimitedLinkedHashMap<InetAddress, RemoteDevice>(
				getResources().getInteger(R.integer.recently_connected_count));

		keyStoreManager = new KeyStoreManager(this);
		loadConfig();
	}

	@Override
	public void onDestroy() {
		storeConfig();
		cleanupSocket();
		if (keyStoreManager != null) {
			keyStoreManager.store();
		}
		super.onDestroy();
	}

	@Override
	public IBinder onBind(Intent intent) {
		return new LocalBinder();
	}

	/**
	 * Determines whether a port number is valid.
	 *
	 * @param     port    an integer representing the port number
	 * @return    {@code true} if the number falls within the range of valid ports
	 */
	private static boolean isPortValid(int port) {
		return port > MIN_PORT && port < MAX_PORT;
	}

	/**
	 * Validates a connection configuration.
	 *
	 * @param     name    a string representing the name of the target
	 * @param     ip      a string representing the ip of the target
	 * @param     port    an integer representing the target's remote port
	 * @return    {@code true} if the configuration is valid
	 */
	private static boolean isConfigValid(String name, String ip, int port) {
		return !INVALID_TARGET.equals(name)
				&& !INVALID_IP.equals(ip)
				&& isPortValid(port);
	}

	private void cleanupSocket() {
		if (sendSocket == null) {
			return;
		}
		Log.i(LOG_TAG, "Closing connection to " + sendSocket.getInetAddress() +
				":" + sendSocket.getPort());
		if (anymoteSender != null) {
			anymoteSender.disconnect();
			anymoteSender = null;
		}
		try {
			sendSocket.close();
		} catch (IOException e) {
			Log.e(LOG_TAG, "failed to close socket");
		}
		sendSocket = null;
	}

	/**
	 * Stores the service's configuration to saved preferences.
	 *
	 * @return      {@code true} if the config was saved
	 */
	private boolean storeConfig() {
		SharedPreferences pref
		= getSharedPreferences(SHARED_PREF_NAME, MODE_PRIVATE);
		SharedPreferences.Editor prefEdit = pref.edit();
		prefEdit.clear();

		if (target != null) {
			storeRemoteDevice(prefEdit, "", target);
		}
		int index = 0;
		for (RemoteDevice remoteDevice : recentlyConnected.values()) {
			storeRemoteDevice(prefEdit, "_" + index, remoteDevice);
			++index;
		}
		if (target != null || index > 0) {
			prefEdit.commit();
			return true;
		}
		return false;
	}

	private void storeRemoteDevice(SharedPreferences.Editor prefEdit,
			String suffix, RemoteDevice remoteDevice) {
		prefEdit.putString(DEVICE_NAME_TAG + suffix, remoteDevice.getName());
		prefEdit.putString(DEVICE_IP_TAG + suffix,
				remoteDevice.getAddress().getHostAddress());
		prefEdit.putInt(DEVICE_PORT_TAG + suffix, remoteDevice.getPort());
	}

	/**
	 * Loads an existing configuration, and builds the socket to the target.
	 */
	private void loadConfig() {
		SharedPreferences pref
		= getSharedPreferences(SHARED_PREF_NAME, MODE_PRIVATE);

		RemoteDevice restoredTarget = loadRemoteDevice(pref, "");

		for (int i = 0; i < getResources()
				.getInteger(R.integer.recently_connected_count); ++i) {
			RemoteDevice remoteDevice = loadRemoteDevice(pref, "_" + i);
			if (remoteDevice != null) {
				recentlyConnected.put(remoteDevice.getAddress(), remoteDevice);
			}
		}

		if (restoredTarget != null) {
			setTarget(restoredTarget);
		}
	}

	private RemoteDevice loadRemoteDevice(SharedPreferences pref, String suffix) {
		String name = pref.getString(DEVICE_NAME_TAG + suffix, INVALID_TARGET);
		String ip = pref.getString(DEVICE_IP_TAG + suffix, INVALID_IP);
		int port = pref.getInt(DEVICE_PORT_TAG + suffix, INVALID_PORT);
		if (!isConfigValid(name, ip, port)) {
			return null;
		}
		InetAddress address;
		try {
			address = InetAddress.getByName(ip);
		} catch (UnknownHostException e) {
			return null;
		}
		return new RemoteDevice(name, address, port);
	}

	/**
	 * Enables in-process access to this service.
	 */
	final class LocalBinder extends Binder {
		CoreService getService() {
			return CoreService.this;
		}
	}

	public KeyStoreManager getKeyStoreManager() {
		return keyStoreManager;
	}

	private void addRecentlyConnected(RemoteDevice remoteDevice) {
		recentlyConnected.remove(remoteDevice.getAddress());
		recentlyConnected.put(remoteDevice.getAddress(), remoteDevice);
		storeConfig();
	}

	// CONNECTION MANAGER

	private enum Request {
		CONNECT,
		CONNECTED,
		SET_TARGET,
		DISCONNECT,
		CONNECTION_ERROR,
		SET_KEEP_CONNECTED,
		REQUEST_PAIRING,
		PAIRING_FINISHED,
		REQUEST_DEVICE_FINDER,
		DEVICE_FINDER_FINISHED,
	}

	public void notifyConnectionFailed() {
		sendMessage(Request.CONNECTION_ERROR, null);
	}

	public void connect(ConnectionListener listener) {
		sendMessage(Request.CONNECT, listener);
	}

	public void connected(ConnectionResult result) {
		sendMessage(Request.CONNECTED, result);
	}

	public void disconnect(ConnectionListener listener) {
		sendMessage(Request.DISCONNECT, listener);
	}

	public void setKeepConnected(boolean keepConnected) {
		sendMessage(Request.SET_KEEP_CONNECTED, Boolean.valueOf(keepConnected));
	}

	public void setTarget(RemoteDevice remoteDevice) {
		sendMessage(Request.SET_TARGET, remoteDevice);
	}

	public RemoteDevice getTarget() {
		return target;
	}

	public ArrayList<RemoteDevice> getRecentlyConnected() {
		ArrayList<RemoteDevice> devices = new ArrayList<RemoteDevice>(
				recentlyConnected.values());
		Collections.reverse(devices);
		return devices;
	}

	public void pairingFinished() {
		sendMessage(Request.PAIRING_FINISHED, null);
	}

	public void deviceFinderFinished() {
		sendMessage(Request.DEVICE_FINDER_FINISHED, null);
	}

	public void requestDeviceFinder() {
		sendMessage(Request.REQUEST_DEVICE_FINDER, null);
	}

	private void requestPairing() {
		sendMessage(Request.REQUEST_PAIRING, null);
	}

	private void sendMessage(Request request, Object obj) {
		Message msg = handler.obtainMessage(request.ordinal());
		msg.obj = obj;
		handler.dispatchMessage(msg);
	}

	private class ConnectionRequestCallback implements Handler.Callback {
		private int keepConnectedRefcount;
		private State currentState = State.IDLE;
		private boolean pendingNotification;

		private boolean changeState(State newState) {
			return changeState(newState, null);
		}

		private boolean changeState(State newState, Runnable callback) {
			if (isTransitionLegal(currentState, newState)) {
				if (Debug.isDebugConnection()) {
					Log.d(LOG_TAG, "Changing state: " + currentState + " -> " + newState);
				}
				currentState = newState;
				if (callback != null) {
					callback.run();
				}
				sendNotification();
				return true;
			}
			if (Debug.isDebugConnection()) {
				Log.d(LOG_TAG, "Illegal transition: " + currentState + " -> "
						+ newState);
			}
			return false;
		}

		public boolean handleMessage(Message msg) {
			Request request = Request.values()[msg.what];
			Log.v(LOG_TAG, "handleMessage:" + request + " (" + msg.obj + ")");
			switch (request) {
			case CONNECT:
				handleConnect((ConnectionListener) msg.obj);
				return true;

			case CONNECTED:
				connectionTask = null;
				handleConnected((ConnectionResult) msg.obj);
				return true;

			case DISCONNECT:
				handleDisconnect((ConnectionListener) msg.obj);
				return true;

			case SET_TARGET:
				handleSetTarget((RemoteDevice) msg.obj);
				return true;

			case CONNECTION_ERROR:
				handleConnectionError();
				return true;

			case SET_KEEP_CONNECTED:
				handleSetKeepConnected((Boolean) msg.obj);
				return true;

			case REQUEST_DEVICE_FINDER:
				handleRequestDeviceFinder();
				return true;

			case REQUEST_PAIRING:
				handleRequestPairing();
				return true;

			case PAIRING_FINISHED:
				changeState(State.IDLE);
				return true;

			case DEVICE_FINDER_FINISHED:
				changeState(State.IDLE);
				return true;
			}
			return false;
		}

		private boolean isConnected() {
			return Debug.isDebugConnectionLess() || sendSocket != null;
		}

		private boolean isConnecting() {
			return State.CONNECTING.equals(currentState);
		}

		private void handleConnectionError() {
			if (changeState(State.DISCONNECTING)) {
				cleanupSocket();
			}
			if (changeState(State.CONNECTING)) {
				connect();
			}
		}

		private void handleConnect(ConnectionListener listener) {
			handleSetKeepConnected(true);

			if (listener != connectionListener) {
				connectionListener = listener;
				if (pendingNotification) {
					sendNotification();
				} else if (isConnecting() || isConnected()) {
					sendNotification();
				}
			}

			if (target != null && changeState(State.CONNECTING)) {
				connect();
			} else if (target == null) {
				changeState(State.DEVICE_FINDER);
			}
		}

		private void handleRequestDeviceFinder() {
			stopConnectionTask();
			disconnect(true);
			changeState(State.DEVICE_FINDER);
		}

		private void handleRequestPairing() {
			stopConnectionTask();
			changeState(State.PAIRING);
		}

		private void handleConnected(final ConnectionResult result) {
			stopConnectionTask();
			if (sendSocket != null) {
				throw new IllegalStateException();
			}
			changeState(State.CONNECTED, new Runnable() {
				public void run() {
					addRecentlyConnected(target);
					anymoteSender = result.sender;
					sendSocket = result.socket;
					tempStbId = result.stbID;
				}
			});
		}
		
		private String tempStbId;

		private void handleDisconnect(ConnectionListener listener) {
			handleSetKeepConnected(false);
			if (listener == connectionListener) {
				connectionListener = null;
			}
		}

		private void handleSetKeepConnected(boolean keepConnected) {
			keepConnectedRefcount += keepConnected ? 1 : -1;
			if (Debug.isDebugConnection()) {
				Log.d(LOG_TAG, "KeepConnectedRefcount: " + keepConnectedRefcount);
			}
			if (keepConnectedRefcount < 0) {
				throw new IllegalStateException("KeepConnectedRefCount < 0");
			}
			if (connectionListener == null) {
				disconnect(false);
			}
		}

		private void handleSetTarget(RemoteDevice remoteDevice) {
			disconnect(true);
			target = remoteDevice;
			if (target != null && changeState(State.CONNECTING)) {
				connect();
			}
		}

		private void disconnect(boolean unconditionally) {
			if (unconditionally || keepConnectedRefcount == 0) {
				if (isConnected()) {
					changeState(State.DISCONNECTING);
					cleanupSocket();
					changeState(State.IDLE);
				} else if (isConnecting()) {
					changeState(State.DISCONNECTING);
					stopConnectionTask();
					changeState(State.IDLE);
				}
			}
		}

		private void connect() {
			if (Debug.isDebugConnection()) {
				Log.d(LOG_TAG, "Connecting to: " + target);
			}
			if (sendSocket != null) {
				throw new IllegalStateException("Already connected");
			}
			if (target == null) {
				changeState(State.DEVICE_FINDER);
				return;
			}
			startConnectionTask(target);
		}

		private void sendNotification() {
			if (connectionListener == null) {
				pendingNotification = true;
				if (Debug.isDebugConnection()) {
					Log.d(LOG_TAG, "Pending notification: " + currentState);
				}
				return;
			}
			pendingNotification = false;
			if (Debug.isDebugConnection()) {
				Log.d(LOG_TAG, "Sending notification: " + currentState + " to "
						+ connectionListener);
			}
			switch (currentState) {
			case IDLE:
				break;

			case CONNECTING:
				connectionListener.onConnecting();
				break;

			case CONNECTED:
				connectionListener.onConnectionSuccessful(
						Debug.isDebugConnectionLess()
						? new DummySender() : anymoteSender);
				connectionListener.onConnectionStbId(tempStbId);
				break;

			case DISCONNECTING:
				connectionListener.onDisconnected();
				break;

			case DEVICE_FINDER:
				connectionListener.onShowDeviceFinder();
				break;

			case PAIRING:
				if (target != null) {
					connectionListener.onNeedsPairing(target);
				} else {
					connectionListener.onShowDeviceFinder();
				}
				break;

			default:
				throw new IllegalStateException("Unsupported state: " + currentState);
			}
		}
	}

	private void startConnectionTask(RemoteDevice remoteDevice) {
		stopConnectionTask();
		connectionTask = new ConnectionTask(this);
		connectionTask.execute(remoteDevice);
	}

	private void stopConnectionTask() {
		if (connectionTask != null) {
			connectionTask.cancel(true);
			connectionTask = null;
		}
	}

	private static class ConnectionResult {
		final ConnectionStatus status;
		final AnymoteSender sender;
		final Socket socket;
		String stbID;

		private ConnectionResult(ConnectionStatus status, AnymoteSender sender, Socket socket , String stbID) {
			this.status = status;
			this.sender = sender;
			this.socket = socket;
			this.stbID = stbID;
		}
	}

	private static class ConnectionTask extends AsyncTask<RemoteDevice, Void, ConnectionResult> {

		private final CoreService coreService;
		private AnymoteSender sender;
		private Socket socket;

		private ConnectionTask(CoreService coreService) {
			this.coreService = coreService;
		}

		@Override
		protected ConnectionResult doInBackground(RemoteDevice... params) {
			if (params.length != 1) {
				throw new IllegalStateException("Expected exactly one remote device");
			}
			for (int i = 0; i <= MAX_CONNECTION_ATTEMPTS; ++i) {
				try {
					Thread.sleep(RECONNECTION_DELAY_MS * i);
				} catch (InterruptedException e) {
					return null;
				}

				sender = null;
				socket = null;

				if (isCancelled()) {
					return null;
				}
				ConnectionStatus status = buildSocket(params[0]);
				if (isCancelled()) {
					return null;
				}
				switch (status) {
				case OK:
					return new ConnectionResult(status, sender, socket , params[0].getName());

				case ERROR_HANDSHAKE:
					return new ConnectionResult(status, null, null,null);

				case ERROR_CREATE:
					// try to reconnect
					break;

				default:
					throw new IllegalStateException("Unsupported status: " + status);
				}
			}
			return new ConnectionResult(ConnectionStatus.ERROR_CREATE, null, null,null);
		}

		private ConnectionStatus buildSocket(RemoteDevice target) {
			if (target == null) {
				throw new IllegalStateException();
			}

			// Set up the new connection.
			try {
				socket = getSslSocket(target);
			} catch (SSLException e) {
				Log.e(LOG_TAG, "(SSL) Could not create socket to " + target, e);
				return ConnectionStatus.ERROR_HANDSHAKE;
			} catch (GeneralSecurityException e) {
				Log.e(LOG_TAG, "(GSE) Could not create socket to " + target, e);
				return ConnectionStatus.ERROR_HANDSHAKE;
			} catch (IOException e) {
				// Hack for Froyo which throws IOException for SSL handshake problem:
				if (e.getMessage().startsWith("SSL handshake")) {
					return ConnectionStatus.ERROR_HANDSHAKE;
				}
				Log.e(LOG_TAG, "(IOE) Could not create socket to " + target, e);
				return ConnectionStatus.ERROR_CREATE;
			}
			Log.i(LOG_TAG, "Connected to " + target);
			if (isCancelled()) {
				return ConnectionStatus.ERROR_CREATE;
			}
			sender = new AnymoteSender(coreService);
			if (!sender.setSocket(socket)) {
				Log.e(LOG_TAG, "Initial message failed");
				sender.disconnect();
				try {
					socket.close();
				} catch (IOException e) {
					Log.e(LOG_TAG, "failed to close socket");
				}
				return ConnectionStatus.ERROR_CREATE;
			}

			// Connection successful - we need to reset connection attempts counter,
			// so next time the connection will drop we will try reconnecting.
			return ConnectionStatus.OK;
		}

		/**
		 * Generates an SSL-enabled socket.
		 *
		 * @return the new socket
		 * @throws GeneralSecurityException on error building the socket
		 * @throws IOException on error loading the KeyStore
		 */
		private SSLSocket getSslSocket(RemoteDevice target)
				throws GeneralSecurityException, IOException {
			// Build a new key store based on the key store manager.
			KeyManager[] keyManagers = coreService.getKeyStoreManager()
					.getKeyManagers();
			TrustManager[] trustManagers = coreService.getKeyStoreManager()
					.getTrustManagers();

			if (keyManagers.length == 0) {
				throw new IllegalStateException("No key managers");
			}

			// Create a new SSLContext, using the new KeyManagers and TrustManagers
			// as the sources of keys and trust decisions, respectively.
			SSLContext sslContext = SSLContext.getInstance("TLS");
			sslContext.init(keyManagers, trustManagers, null);

			// Finally, build a new SSLSocketFactory from the SSLContext, and
			// then generate a new SSLSocket from it.
			SSLSocketFactory factory = sslContext.getSocketFactory();
			SSLSocket sock = (SSLSocket) factory.createSocket();
			sock.setNeedClientAuth(true);
			sock.setUseClientMode(true);
			sock.setKeepAlive(true);
			sock.setTcpNoDelay(true);

			InetSocketAddress fullAddr =
					new InetSocketAddress(target.getAddress(), target.getPort());
			sock.connect(fullAddr, SOCKET_CREATION_TIMEOUT_MS);
			sock.startHandshake();

			return sock;
		}

		// Notifications

		@Override
		protected void onCancelled() {
			super.onCancelled();
		}

		@Override
		protected void onPostExecute(ConnectionResult result) {
			super.onPostExecute(result);
			switch (result.status) {
			case OK:
				coreService.connected(result);
				break;
			case ERROR_CREATE:
				coreService.requestDeviceFinder();
				break;
			case ERROR_HANDSHAKE:
				coreService.requestPairing();
				break;
			}
		}
	}

	// State transition management

	private static Map<State, Set<State>> allowedTransitions() {
		Map<State, Set<State>> allowedTransitions = new HashMap<State, Set<State>>();

		allowedTransitions.put(State.IDLE, EnumSet.of(State.IDLE, State.CONNECTING,
				State.DEVICE_FINDER));
		allowedTransitions.put(State.CONNECTING, EnumSet.of(State.CONNECTED,
				State.DEVICE_FINDER, State.PAIRING, State.DISCONNECTING));
		allowedTransitions.put(State.CONNECTED, EnumSet.of(State.DISCONNECTING));
		allowedTransitions.put(State.DEVICE_FINDER, EnumSet.of(State.IDLE));
		allowedTransitions.put(State.PAIRING, EnumSet.of(State.IDLE));
		allowedTransitions.put(State.DISCONNECTING, EnumSet.of(State.IDLE,
				State.CONNECTING));

		for (State state : State.values()) {
			if (!allowedTransitions.containsKey(state)) {
				throw new IllegalStateException("Incomplete transition map");
			}
		}
		return allowedTransitions;
	}

	private static boolean isTransitionLegal(State from, State to) {
		return ALLOWED_TRANSITION.get(from).contains(to);
	}

	@Override
	public void onConnectionStbId(String stbId) {
		// TODO Auto-generated method stub
		
	}
}
